Skip to content

Fix cursor pagination with optional tie-breaker#2080

Merged
ravikiranvm merged 10 commits intomainfrom
ops-3810
Mar 17, 2026
Merged

Fix cursor pagination with optional tie-breaker#2080
ravikiranvm merged 10 commits intomainfrom
ops-3810

Conversation

@ravikiranvm
Copy link
Copy Markdown
Contributor

@ravikiranvm ravikiranvm commented Mar 6, 2026

Fixes OPS-3810.

Additional Notes

We create benchmark workflows in bulk, so many rows share the same updated timestamp. With cursor pagination, page boundaries previously filtered using only updated < cursor, which caused rows with the same timestamp to be skipped across pages. This PR adds support for an optional secondary pagination key (used as a secondary sort key) to break ties when primary timestamps are identical.

@linear
Copy link
Copy Markdown

linear bot commented Mar 6, 2026

@ravikiranvm ravikiranvm marked this pull request as ready for review March 9, 2026 08:05
Copilot AI review requested due to automatic review settings March 9, 2026 08:05
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds optional composite cursor pagination (primary + secondary “tie-breaker” column) to prevent skipping/duplication when multiple rows share the same primary pagination value (OPS-3810), and wires it into Flow listing.

Changes:

  • Extend Paginator to optionally encode/decode a secondary cursor field and apply composite cursor filtering.
  • Extend buildPaginator options to accept a secondary pagination column.
  • Add integration tests covering composite pagination behavior when many rows share the same primary timestamp.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.

File Description
packages/server/api/test/integration/helper/pagination/paginator.integration.test.ts Adds integration coverage for composite pagination using a secondary ID tie-breaker.
packages/server/api/src/app/helper/pagination/paginator.ts Implements secondary cursor key support and composite cursor filtering + ordering.
packages/server/api/src/app/helper/pagination/build-paginator.ts Exposes secondary pagination configuration via builder options.
packages/server/api/src/app/flows/flow/flow.service.ts Uses the new secondary tie-breaker pagination for flow listing.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread packages/server/api/src/app/helper/pagination/paginator.ts Outdated
Comment thread packages/server/api/src/app/helper/pagination/paginator.ts
Comment thread packages/server/api/src/app/helper/pagination/build-paginator.ts Outdated
Copy link
Copy Markdown
Contributor

@bigfluffycookie bigfluffycookie left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

its good that you refactor things, but as a reviewer I now need to figure out which parts are changes for the fix, and which parts you just moved around. Please add comments explaining which parts are refactor and which parts are changes.

Comment thread packages/server/api/src/app/helper/pagination/paginator.ts Outdated
Comment thread packages/server/api/src/app/helper/pagination/paginator.ts
Comment on lines -176 to -181
const isCustomColumn =
this.paginationColumnName && cursors[CUSTOM_PAGINATION_KEY];
const columnName = isCustomColumn
? this.paginationColumnName
: `${this.alias}.${PAGINATION_KEY}`;
const paramName = isCustomColumn ? CUSTOM_PAGINATION_KEY : PAGINATION_KEY;
Copy link
Copy Markdown
Contributor Author

@ravikiranvm ravikiranvm Mar 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We compute whether the custom pagination key is being used and derive columnName and paramName from that. I moved that logic into resolveCursorContext, and it now also supports resolving the secondary column and parameter name when composite pagination is active.

private resolveCursorContext(cursors: CursorParam): CursorContext {

Comment on lines -186 to -189
if (this.hasBeforeCursor() && !this.hasAfterCursor()) {
queryString = `${columnName} ${operator} (:${paramName}::timestamp + INTERVAL '1 millisecond')`;
} else {
queryString = `${columnName} ${operator} :${paramName}::timestamp`;
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The query-string construction logic is now split across three helpers:

  • applySingleColumnCursorFilter
  • applyCompositeCursorFilter
  • buildComparisonClause

where the first two decide the shape of the filter, and buildComparisonClause builds the DB comparison fragments they use.

return order === Order.ASC ? Order.DESC : Order.ASC;
}

private buildComparisonClause({
Copy link
Copy Markdown
Contributor Author

@ravikiranvm ravikiranvm Mar 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We used to build the DB specific comparison inline for a single pagination key. This logic is now extracted and expanded to support both timestamp and non timestamp fields, because pagination now needs to compare both the primary key and the secondary key.

The main functional addition here is the timestamp = handling with a 1ms window, which is used to treat rows in the same timestamp bucket as equal before applying the secondary key.

);
}

private applyCompositeCursorFilter(
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the main functional change for the fix. It builds two branches in the WHERE condition: the first branch handles rows that are already beyond the cursor based on the primary key, and the second branch handles rows that have the same primary value as the cursor row and therefore need the secondary key to decide paging order correctly. Earlier only the first branch existed, which is why rows with the same primary value could be skipped.

@sonarqubecloud
Copy link
Copy Markdown

@ravikiranvm ravikiranvm merged commit 173cc01 into main Mar 17, 2026
21 checks passed
@ravikiranvm ravikiranvm deleted the ops-3810 branch March 17, 2026 06:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants